Esquema de base de datos local (SQLite) y arquitectura de sincronización offline-first para el POS. Documento de referencia para el equipo de desarrollo — acompaña al prototipo de la pantalla de ventas.
Cuatro dominios: catálogo, ventas, cuenta corriente y caja. Cada registro generado localmente usa TEXT con UUID como clave primaria para evitar colisiones al sincronizar (ver sección 02).
Un producto puede tener varios códigos (presentaciones, lotes reempaquetados), por eso los códigos viven en su propia tabla 1‑a‑N.
CREATE TABLE products ( id TEXT PRIMARY KEY, -- UUID name TEXT NOT NULL, category TEXT, cost_price INTEGER NOT NULL, -- centavos, evita floats sale_price INTEGER NOT NULL, stock INTEGER NOT NULL DEFAULT 0, min_stock INTEGER NOT NULL DEFAULT 0, unit TEXT DEFAULT 'un', -- un | kg | carga price_version INTEGER NOT NULL DEFAULT 1, -- ver sync updated_at TEXT NOT NULL, -- ISO 8601 UTC is_active INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE product_barcodes ( barcode TEXT PRIMARY KEY, -- el código ES la clave product_id TEXT NOT NULL REFERENCES products(id) ); CREATE INDEX idx_barcode ON product_barcodes(barcode);
El detalle congela el precio al momento de la venta (unit_price) para que un cambio de precio posterior no altere tickets históricos.
CREATE TABLE sales ( id TEXT PRIMARY KEY, -- UUID generado offline shift_id TEXT NOT NULL REFERENCES shifts(id), total INTEGER NOT NULL, payment TEXT NOT NULL, -- efectivo|debito|credito|billetera|fiado cash_given INTEGER, -- solo efectivo change_due INTEGER, -- vuelto client_id TEXT REFERENCES clients(id),-- solo fiado sold_at TEXT NOT NULL, -- ISO 8601 UTC sync_status TEXT NOT NULL DEFAULT 'pending' -- pending|synced ); CREATE TABLE sale_items ( id TEXT PRIMARY KEY, sale_id TEXT NOT NULL REFERENCES sales(id), product_id TEXT NOT NULL REFERENCES products(id), qty INTEGER NOT NULL, unit_price INTEGER NOT NULL, -- precio congelado line_total INTEGER NOT NULL );
El saldo deudor se mantiene como columna desnormalizada para lectura rápida en caja, pero cada movimiento queda registrado para auditoría.
CREATE TABLE clients ( id TEXT PRIMARY KEY, name TEXT NOT NULL, phone TEXT, balance INTEGER NOT NULL DEFAULT 0, -- saldo deudor credit_limit INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ); CREATE TABLE account_movements ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL REFERENCES clients(id), sale_id TEXT REFERENCES sales(id), -- NULL si es un pago amount INTEGER NOT NULL, -- + fía / − paga kind TEXT NOT NULL, -- charge | payment created_atTEXT NOT NULL );
Los totales por método se calculan sumando sales del turno; en shifts sólo se persiste apertura, cierre y conteo real para detectar diferencias.
CREATE TABLE shifts ( id TEXT PRIMARY KEY, cashier TEXT NOT NULL, opening_float INTEGER NOT NULL, -- saldo inicial efectivo opened_at TEXT NOT NULL, closed_at TEXT, -- NULL = turno abierto counted_cash INTEGER, -- conteo real al cierre expected_cash INTEGER, -- float + ventas efectivo difference INTEGER -- sobrante / faltante );
El comercio opera siempre contra SQLite local; la nube es un espejo eventual. Dos flujos independientes y unidireccionales que nunca compiten por el mismo dato, eliminando la mayoría de los conflictos por diseño.
Si cada caja usara AUTOINCREMENT, dos comercios (o dos cajas offline) generarían la venta #1042 y colisionarían al subir a una base central. La solución es que el cliente genere el ID con un UUID v4/v7 al crear el registro, offline, sin coordinación con el servidor.
pending → synced. La nube nunca lo ve.El catálogo es de una sola dirección: lo edita el dueño en el panel central, nunca el cajero. Por eso no hay conflicto: el local sólo lee.
Cada cambio de precio incrementa price_version global.
Al recuperar internet envía su max(price_version) conocido y recibe sólo el delta.
INSERT … ON CONFLICT(id) DO UPDATE. Como el id es estable, actualizar es idempotente: aplicar dos veces el mismo delta no rompe nada.
Las ventas son inmutables y append-only: una vez cobradas no se editan. Esto las hace triviales de sincronizar — sólo se insertan, nunca se actualizan.
sync_status='pending'La venta queda firme en SQLite aunque no haya internet. El cajero nunca espera a la red.
Cuando hay conexión, sube en lote todas las pending con su UUID ya asignado.
Si una venta llega dos veces (reintento por timeout), el UUID duplicado se ignora con ON CONFLICT DO NOTHING. Cero ventas dobladas.
syncedEl servidor responde los UUID aceptados; el local los marca y deja de reenviarlos.
ON CONFLICT).updated_at más reciente.